들어가며

지난 글에서는 코드를 액션 / 계산 / 데이터로 나누는 것까지 정리했습니다. 그런데 “액션에서 계산을 빼내라”는 말은 방향만 알려줄 뿐, 실제로 어디를 잡고 뜯어내야 하는지는 알려주지 않습니다.

이번 글에서는 그 구체적인 방법을 정리합니다.

  • 입력과 출력 — 무엇을 기준으로 액션과 계산이 갈리는가
  • 더 나은 추상화 단계 선택하기 — 어디까지 쪼개야 하는가
  • 카피-온-라이트 — 불변성을 어떻게 실용적으로 지키는가
  • 계층형 설계 — 그렇게 나눈 함수들을 어떻게 쌓을 것인가

입력과 출력

함수에는 두 종류의 입출력이 있습니다.

  • 명시적 입력: 인자 / 명시적 출력: 리턴값
  • 암묵적 입력: 인자 외의 모든 입력 / 암묵적 출력: 리턴값 외의 모든 출력

그리고 규칙은 단순합니다.

함수에 암묵적 입력이나 출력이 하나라도 있으면, 그 함수는 액션입니다.

전역 변수를 읽는 것, this를 읽는 것, DB를 조회하는 것, 현재 시각을 읽는 것 전부 암묵적 입력입니다. 전역 변수를 바꾸는 것, 인자로 받은 객체를 변경하는 것, DOM을 건드리는 것, 콘솔에 찍는 것은 전부 암묵적 출력입니다.

액션에서 계산 빼내기

그래서 액션에서 계산을 빼내는 작업은 결국 이렇게 정리됩니다.

암묵적 입력은 인자로, 암묵적 출력은 리턴값으로 바꾼다.

장바구니 합계를 구하는 코드로 보겠습니다.

let shoppingCart = []
let cartTotal = 0

function calcCartTotal() {
  cartTotal = 0 // 암묵적 출력: 전역 변수를 변경합니다

  for (const item of shoppingCart) {
    // 암묵적 입력: 전역 변수를 읽습니다
    cartTotal += item.price
  }

  setCartTotalDom(cartTotal) // 암묵적 출력: DOM을 변경합니다
}

이 함수는 인자도 없고 리턴값도 없습니다. 겉보기에는 아무것도 주고받지 않는 것 같지만, 실제로는 전역 변수와 DOM을 통해 바깥과 활발하게 소통하고 있습니다. 이게 가장 다루기 어려운 형태입니다.

여기서 계산을 빼내면 이렇게 됩니다.

// 계산: 암묵적 입출력이 전부 사라졌습니다
function calcTotal(cart) {
  let total = 0

  for (const item of cart) {
    total += item.price
  }

  return total
}

// 액션: 암묵적 입출력을 담당하는 얇은 껍데기만 남습니다
function updateCartTotal() {
  cartTotal = calcTotal(shoppingCart)
  setCartTotalDom(cartTotal)
}

calcTotal은 이제 전역 변수도, DOM도, 브라우저도 필요 없습니다. 배열 하나만 넘기면 테스트할 수 있습니다.

암묵적 입출력이 많을수록 테스트가 어려워진다는 건 그래서 자연스러운 결론입니다. 테스트를 짜기 위해 준비해야 할 바깥 세계가 그만큼 많아지기 때문입니다.

연관 질문

Q. 컴포넌트 안의 함수가 주입받은 서비스의 인스턴스 변수를 바로 쓰는 것은 암묵적 입력인가요?

네, 암묵적 입력입니다. 애초에 클래스 메서드는 this라는 암묵적 입력을 하나 달고 시작합니다.

export class CartComponent {
  private cartStore = inject(CartStore)

  // 인자가 없는데 결과는 cartStore의 현재 상태에 따라 달라집니다
  getTotal() {
    return this.cartStore.items.reduce((sum, item) => sum + item.price, 0)
  }
}

getTotal을 테스트하려면 컴포넌트를 만들고, CartStore를 목으로 주입하고, 상태를 세팅해야 합니다. 계산 하나 검증하자고 준비할 게 너무 많습니다.

// 계산: 파일 하나로 분리하면 어디서든 부를 수 있습니다
export function calcTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// 컴포넌트는 상태를 계산에 넘기는 역할만 합니다
export class CartComponent {
  private cartStore = inject(CartStore)

  total = computed(() => calcTotal(this.cartStore.items()))
}

컴포넌트는 “상태를 가져와 계산에 넘기고 화면에 뿌리는” 얇은 층이 되고, 판단 로직은 밖으로 나와 순수함수가 됩니다.


Q. 인자는 되도록 연관성 있는 이름으로, RORO 형식으로 넘기는 게 좋지 않을까?

동의합니다. RORO(Receive an Object, Return an Object)는 인자와 리턴을 객체로 주고받는 패턴입니다.

// 호출부만 봐서는 true, false가 무엇인지 알 수 없습니다
createUser('연재', 30, true, false)

// 호출부만 봐도 읽힙니다
createUser({ name: '연재', age: 30, isAdmin: true, isBlocked: false })

인자 순서를 외우지 않아도 되고, 선택 인자를 나중에 추가하기 쉽고, 구조 분해로 받기도 좋습니다.

다만 인자가 한두 개일 때까지 객체로 감싸면 오히려 장황해집니다. 인자가 세 개를 넘어가거나 같은 타입이 연달아 나올 때 쓰는 정도가 적당하다고 생각합니다.


Q. 가비지 컬렉터는 신인가?

이 질문은 뒤에서 다시 나옵니다.


더 나은 추상화 단계 선택하기

절차적인 코드를 함수형으로 옮기는 과정은 결국 추상화입니다. 그리고 함수형 프로그래밍에서 추상화의 대상은 두 가지입니다.

  • 계산의 추상화: 반복되는 판단 로직을 이름 있는 함수로 만든다
  • 데이터의 추상화: 흩어진 값들을 의미 있는 하나의 구조로 묶는다

배열을 직접 순회하며 for 문을 쓰던 코드를 map, filter, reduce로 바꾸는 것이 계산의 추상화입니다. for 문은 “인덱스를 0부터 하나씩 늘리며 접근한다”는 절차를 말하지만, map은 “각 항목을 변환한다”는 의도를 말합니다. 같은 일을 하지만 읽는 사람이 신경 쓸 단계가 한 칸 올라갑니다.

연관 질문

Q. 어디까지 추상화해야 하나요? 함수를 위한 함수를 위한 함수를 위한…

이해할 수 있을 만큼입니다.

추상화는 공짜가 아닙니다. 한 단계 올릴 때마다 “이 함수가 실제로 무슨 일을 하는가”를 알기 위해 따라가야 할 파일이 하나씩 늘어납니다. 재사용되지도 않고 이름으로 의미가 더 명확해지지도 않는 추상화라면, 그건 그냥 코드를 흩뿌린 것에 가깝습니다.

추상화를 하나 만들 때 스스로에게 물어볼 기준은 이 정도라고 생각합니다.

  • 이름만 보고도 무슨 일을 하는지 알 수 있는가
  • 안을 열어보지 않아도 호출부를 읽을 수 있는가
  • 두 군데 이상에서 쓰이거나, 최소한 테스트할 가치가 있는가

카피-온-라이트

이제 데이터를 다루는 이야기입니다.

자바스크립트의 객체와 배열은 참조로 전달됩니다. 그래서 중첩된 데이터를 아무 생각 없이 다루면, 내가 바꾼 적 없다고 생각한 값이 다른 곳에서 바뀌어 있는 상황이 생깁니다.

카피-온-라이트는 이 문제를 이렇게 해결합니다.

원본을 바꾸지 않고, 바뀐 복사본을 새로 만들어 돌려준다.

절차는 세 단계입니다.

  1. 복사본을 만든다
  2. 복사본을 변경한다
  3. 복사본을 리턴한다
// 쓰기: 원본을 직접 바꿉니다 → 액션
function addItem(cart, item) {
  cart.push(item)
}

// 카피-온-라이트: 원본은 그대로 두고 새 배열을 돌려줍니다 → 계산
function addItem(cart, item) {
  const newCart = [...cart] // 1. 복사
  newCart.push(item) // 2. 변경
  return newCart // 3. 리턴
}

여기서 중요한 건 쓰기가 읽기로 바뀌었다는 점입니다. 원본을 건드리지 않으니 이 함수는 이제 계산입니다. 몇 번을 호출하든 안전하고, 테스트하기도 쉽습니다.

읽기와 쓰기를 섞지 않기

되도록 읽는 함수와 쓰는 함수를 분리하는 편이 좋습니다.

// 읽기와 쓰기가 섞여 있습니다
function removeLast(cart) {
  return cart.pop() // 원본을 바꾸면서, 동시에 값을 돌려줍니다
}

// 읽기
function getLast(cart) {
  return cart[cart.length - 1]
}

// 쓰기 (카피-온-라이트)
function dropLast(cart) {
  return cart.slice(0, -1)
}

pop처럼 “바꾸면서 동시에 알려주는” 함수는 편해 보이지만, 호출한 쪽에서는 원본이 바뀌었다는 사실을 놓치기 쉽습니다. 단계를 명시적으로 드러내는 편이 결국 읽기 좋습니다.

방어적 복사

카피-온-라이트는 내 코드가 규칙을 지킨다는 전제 위에서 동작합니다. 그런데 신뢰할 수 없는 코드(외부 라이브러리, 레거시 모듈)와 데이터를 주고받을 때는 그 전제가 깨집니다.

그때 쓰는 게 방어적 복사입니다. 데이터가 들어올 때 깊은 복사를 하고, 나갈 때도 깊은 복사를 해서 내보냅니다.

function saveToLegacyModule(cart) {
  const copy = structuredClone(cart) // 나갈 때 복사
  legacyModule.save(copy) // 저 안에서 뭘 하든 내 원본은 안전합니다
}

다만 깊은 복사는 얕은 복사보다 훨씬 비쌉니다. 전체 구조를 다 순회해야 하기 때문입니다. 그래서 기본 전략은 카피-온-라이트(얕은 복사)로 가고, 방어적 복사는 신뢰 경계에서만 쓰는 게 맞습니다.

연관 질문

Q. immer는 깊은 복사일까요?

아닙니다. immer가 쓰는 건 구조적 공유(structural sharing) 입니다.

immer는 원본을 Proxy로 감싼 draft를 넘겨주고, 그 draft에 어떤 변경이 일어났는지 기록합니다. 그리고 결과를 만들 때 실제로 바뀐 경로의 노드만 새로 만들고, 손대지 않은 가지는 원본의 참조를 그대로 재사용합니다.

const state = {
  user: { name: '연재', address: { city: '서울' } },
  posts: [
    /* 1000개 */
  ],
}

const next = produce(state, draft => {
  draft.user.name = '연재2'
})

next !== state // true  — 바뀐 경로는 새 객체
next.user !== state.user // true
next.posts === state.posts // true  — 안 건드린 가지는 그대로 공유

posts는 복사되지 않습니다. 그래서 immer는 깊은 복사보다 훨씬 저렴하면서도, 참조 비교(===)만으로 변경 여부를 판단할 수 있게 해줍니다. 리액트나 앵귤러가 얕은 비교로 리렌더링을 결정하는 것과 정확히 맞물리는 지점입니다.


Q. structuredClone은 어떻게 동작하고, 순환 참조는 어떻게 되나요?

structuredClone은 구조화된 복제 알고리즘(structured clone algorithm)을 쓰는 진짜 깊은 복사입니다. 순환 참조도 정상적으로 처리합니다.

const a = { name: 'a' }
a.self = a

const copy = structuredClone(a)
copy.self === copy // true — 순환 구조가 그대로 유지됩니다
copy.self === a // false

내부적으로 이미 방문한 객체를 기억해두고, 같은 객체를 다시 만나면 새로 만들지 않고 이미 만들어둔 복사본을 가리키게 합니다. JSON.parse(JSON.stringify(x))가 순환 참조에서 바로 터지는 것과 다른 지점입니다.

대신 못 하는 것도 분명합니다.

  • 함수, DOM 노드, SymbolDataCloneError
  • 프로토타입 체인 → 유지되지 않습니다. 클래스 인스턴스를 넣으면 평범한 객체가 나옵니다
  • Date, Map, Set, RegExp, ArrayBuffer, Blob 등은 정상 복제됩니다 (JSON 방식으로는 전부 깨지는 것들입니다)

Q. Redux와 immer는 궁합이 좋은가요? Redux Toolkit은?

궁합이 좋은 정도가 아니라, Redux Toolkit은 이미 내부에서 immer를 쓰고 있습니다.

createSlicecreateReducer의 리듀서는 immer의 produce로 감싸져 실행됩니다. 그래서 이렇게 써도 원본은 안전합니다.

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    // 겉보기엔 원본을 바꾸는 것 같지만, state는 draft입니다
    addItem(state, action) {
      state.items.push(action.payload)
    },
  },
})

Redux의 원칙(리듀서는 순수함수여야 한다)과 “직접 바꾸는 것처럼 쓰는 편의성”이 충돌하던 지점을, immer가 Proxy로 중재해준 셈입니다.

한 가지 주의할 점은 draft를 변경하면서 동시에 새 값을 리턴하면 안 된다는 것입니다. 둘 중 하나만 해야 합니다.

// 이렇게 쓰면 안 됩니다
addItem(state, action) {
  state.items.push(action.payload)
  return { ...state, updatedAt: Date.now() }  // 변경 + 리턴 동시에
}

Q. 가비지 컬렉터는 신인가?

신이 맞습니다.

카피-온-라이트는 결국 원본을 버리고 새 객체를 계속 만들어내는 방식입니다. 이게 성립하는 이유는 아무도 참조하지 않게 된 옛 객체를 누군가 조용히 치워주기 때문입니다. 수동으로 메모리를 관리해야 했다면 “일단 복사해서 새로 만든다”는 전략은 애초에 이렇게 편하게 쓸 수 없었을 겁니다.

불변성이라는 개념이 실용적일 수 있는 건, 상당 부분 GC 덕분입니다.


계층형 설계

마지막은 지금까지 만든 함수들을 어떻게 쌓을 것인가에 대한 이야기입니다.

계층형 설계는 소프트웨어를 계층으로 구성하는 기술이고, 각 계층은 바로 아래 계층에 있는 것만 사용합니다. 책에서는 네 가지 패턴으로 정리합니다.

패턴 1. 직접 구현

하나의 함수 안에서는 추상화 단계를 동일하게 유지합니다.

// 단계가 섞여 있습니다
function sendCouponEmails(users) {
  for (let i = 0; i < users.length; i++) {
    // 인덱스를 다루는 저수준
    const rank = users[i].recommendCount > 10 ? 'best' : 'good' // 비즈니스 규칙
    emailService.send(buildEmail(users[i], rank)) // 전송이라는 고수준
  }
}

// 같은 단계의 이야기만 남습니다
function sendCouponEmails(users, coupons) {
  const emails = users.map(user => planEmailForUser(user, coupons))
  return sendEmails(emails)
}

두 번째 함수를 읽을 때는 인덱스나 등급 판정 규칙을 몰라도 됩니다. “계획을 세우고 보낸다”는 한 단계의 이야기만 하기 때문입니다.

함수는 한 가지 일만 해야 한다는 익숙한 규칙도 결국 같은 말입니다. “한 가지 일”이란 “한 단계의 이야기”라는 뜻에 가깝습니다.

패턴 2. 추상화 벽

인터페이스 뒤로 구현을 숨겨서, 벽 위와 아래가 서로를 몰라도 되게 만듭니다.

장바구니를 배열로 구현했든 객체로 구현했든, 벽 위의 코드는 addItem, removeItem, calcTotal만 알면 됩니다. 나중에 자료구조를 바꿔도 벽 위 코드는 손댈 필요가 없습니다.

패턴 3. 작은 인터페이스

추상화 벽에는 최소한의 함수만 둡니다.

벽에 함수를 하나 추가할 때마다, 그 함수는 앞으로 계속 유지해야 하는 약속이 됩니다. 벽 위에서 이미 있는 함수들을 조합해 만들 수 있는 기능이라면, 굳이 벽 안으로 내리지 않는 편이 좋습니다.

패턴 4. 편리한 계층

완벽한 계층이 아니라, 개발이 편해지는 만큼만 만듭니다.

앞의 세 패턴을 끝까지 밀어붙이면 계층이 끝없이 늘어납니다. 하지만 계층형 설계의 목적은 아름다운 구조 자체가 아니라 일하기 편해지는 것입니다. 지금 불편하지 않다면 굳이 더 나누지 않아도 됩니다.

함수를 추가할 때

새 함수를 만들 때는 이렇게 생각해보면 좋습니다.

  • 이 함수는 어느 단계의 이야기를 하고 있는가
  • 이 함수가 호출하는 것들은 전부 나보다 낮은 단계인가
  • 이 함수와 같은 층에 놓일 함수들은 무엇인가

연관 질문

Q. FSD는 계층형 설계인가요?

비슷하지만, 아닙니다.

FSD(Feature-Sliced Design)는 확실히 계층형 설계의 성질을 가지고 있습니다. app → pages → widgets → features → entities → shared 순으로 층이 정해져 있고, 위 층은 아래 층만 import할 수 있다는 단방향 규칙이 있습니다. “각 계층은 아래 계층만 사용한다”는 원칙 그대로입니다.

다만 자르는 축이 다릅니다.

계층형 설계 FSD
단위 함수 (호출 그래프) 디렉터리 / 모듈
자르는 기준 얼마나 추상적인가 무엇에 관한 코드인가
층의 개수 코드에 따라 유동적 6개로 고정

계층형 설계는 함수 호출 그래프를 그렸을 때 추상화 단계가 뒤섞이지 않도록 만드는 코드 수준의 기술입니다. 반면 FSD는 도메인 응집도를 기준으로 폴더를 나누는 아키텍처 규칙입니다. FSD의 entitiesfeatures는 추상화 단계가 다르다기보다 관심사가 다른 것에 가깝습니다.

그래서 정확히 말하면 이렇게 됩니다.

FSD는 계층형 설계의 단방향 의존 규칙을 폴더 구조에 적용한 것이지, 계층형 설계 그 자체는 아니다.

FSD를 잘 지켜도 하나의 feature 안에서 함수들의 추상화 단계는 얼마든지 뒤섞일 수 있습니다. 둘은 대체 관계가 아니라 다른 층위에서 함께 쓰는 도구입니다.


정리

이번 글은 결국 하나의 흐름이었습니다.

  1. 입력과 출력 — 암묵적 입출력이 있으면 액션이다. 이걸 인자와 리턴값으로 바꾸면 계산이 나온다.
  2. 추상화 단계 — 계산과 데이터를 추상화하되, 이해할 수 있을 만큼만 한다.
  3. 카피-온-라이트 — 원본을 바꾸지 않고 복사본을 돌려주면, 쓰기가 읽기로 바뀐다. 즉 액션이 계산이 된다.
  4. 계층형 설계 — 그렇게 만든 계산들을 추상화 단계에 맞춰 쌓는다.

전부 “바깥 세계와의 연결을 줄이고, 판단 로직을 안전한 곳으로 밀어내는” 같은 이야기를 다른 각도에서 하고 있습니다.

특히 카피-온-라이트가 인상적이었습니다. 불변성을 지키자는 말은 많이 들었지만, 그게 액션을 계산으로 바꾸는 구체적인 수단이라는 관점에서 보니 왜 그렇게까지 강조하는지가 이해됐습니다.

다음 글에서는 이렇게 나눈 계산들을 어떻게 조합할지, 함수형 도구와 체이닝을 정리해보겠습니다.